Skip to content

build: create a new release version 1.16.0#1416

Draft
SERDUN wants to merge 478 commits into
mainfrom
release/1.16.0
Draft

build: create a new release version 1.16.0#1416
SERDUN wants to merge 478 commits into
mainfrom
release/1.16.0

Conversation

@SERDUN

@SERDUN SERDUN commented Jun 21, 2026

Copy link
Copy Markdown
Member

Overview

Release 1.16.0 (minor) of webtrit_phone.

  • app_version 1.15.4 -> 1.16.0.
  • callkeep pinned to tag 1.3.0 (release-only git ref; develop keeps the path pin).
  • THIRD_PARTY_LICENSES.md regenerated (callkeep now aggregated as a git dependency, 264 -> 268 packages).
  • Latest source strings pushed to Localizely.

Highlights since 1.15.4 include the force-update gate (WT-1628), network diagnostics, list-based call screen, Play Core in-app update, and the call media manager refactor.

Stays DRAFT during ~2-week QA; merges to main after QA passes.

digiboridev and others added 30 commits April 9, 2026 18:08
…ressed at root (#1090)

* fix(android): move app to background on back press instead of destroying Activity

When pressing Back on the root screen, Android was calling finish() on
MainActivity, destroying the Flutter engine and tearing down any active
WebRTC call (DTLS alert -> ice_hangup -> BYE). Override onBackPressed to
call moveTaskToBack(true) so the app minimizes without destroying the engine.

* fix(android): minimize app instead of destroying Activity when Flutter stack is empty

Override popSystemNavigator() which is called only after Flutter exhausts
its own navigation stack. Replaces the default finish() with moveTaskToBack(true)
so the Flutter engine stays alive with any active WebRTC call.

Previous attempt overrode onBackPressed() which bypassed Flutter router entirely.

* fix(android): handle moveTaskToBack failure and document intentional minimize-always behavior
#1091)

* fix(signaling): make hub protocol bidirectional — remove isolate auto-reconnect

The background foreground-service isolate was managing its own reconnect
timer independently of SignalingReconnectController in the main isolate.
This caused a race: the background could reconnect while the app was locked,
producing a SignalingConnected event that set _wasConnected=true, so the
next disconnect triggered an error toast visible on unlock (green + error).

Root fix: extend the hub protocol with connect/disconnect commands so the
main isolate (SignalingReconnectController via SignalingHubModule) is the
single decision-maker for all reconnects.

Changes:
- signaling_hub_command: add SignalingHubConnectCommand / SignalingHubDisconnectCommand
- signaling_hub_client: add sendConnect() / sendDisconnect() fire-and-forget methods
- signaling_hub: handle connect/disconnect commands, forward to SignalingModule
- signaling_hub_module: connect()/disconnect() forward commands to hub client
  instead of being no-ops
- signaling_foreground_isolate_manager: remove _reconnectTimer and
  _scheduleReconnect(); isolate no longer auto-reconnects on disconnect
  or connection failure — it only reconnects when main isolate calls
  handleStatus(enabled:true) and the module is not connected
- tests: update hub_module_test and foreground_isolate_manager_test to
  cover new behavior; all 158 tests pass

* fix(signaling): handle persistent-service mode reconnect when app is closed

In persistent signaling mode the foreground service outlives the app.
When the app is closed there are no hub subscribers, so
SignalingReconnectController is not running and cannot drive reconnects.

Fix: restore the reconnect timer in SignalingForegroundIsolateManager but
gate it on SignalingHub.hasSubscribers. When subscribers are present (app
open), reconnect is delegated to SignalingReconnectController as before.
When no subscribers are present (app closed, persistent mode), the
background isolate schedules a local reconnect using the delay hint from
the disconnect/failure event. Null delay (e.g. code 1002) is treated as
"do not reconnect" in both modes.

When handleStatus(enabled: true) is called while a timer is pending, the
timer is cancelled so the incoming caller takes responsibility.

- SignalingHub: add hasSubscribers getter
- SignalingForegroundIsolateManager: restore _reconnectTimer and
  _scheduleReconnect(); schedule only when !hub.hasSubscribers
- Tests: 4 new persistent-mode tests covering auto-reconnect on disconnect,
  auto-reconnect on connection failed, no reconnect on null delay, and
  timer cancellation on stop(); all 68 isolate+hub tests pass

* fix(signaling): address Copilot review comments

- signaling_hub: guard connect/disconnect commands with subscriber check —
  reject commands from unknown consumers (consistent with execute command)
- signaling_hub: fix hasSubscribers doc — says "any subscriber" not
  "main-isolate subscriber" (push-notification isolate can also subscribe)
- signaling_hub_module: update class doc — connect/disconnect now forward
  commands to hub client, not no-ops
…gnalingReconnectController (#1089)

* refactor(signaling): centralize disconnect notification decisions in SignalingReconnectController

Move all notification decisions out of CallBloc.__onSignalingClientEventDisconnected
into the single onConnectionFailed callback of SignalingReconnectController.

The callback now receives SignalingDisconnectCode? knownCode so the consumer
can decide what to show:
- signalingKeepaliveTimeoutError / controllerForceAttachClose → silent (no toast)
- sessionMissedError → SignalingSessionMissedNotification
- null (connect failure) → SignalingConnectFailedNotification
- other codes → SignalingDisconnectNotification(knownCode)

__onSignalingClientEventDisconnected now only updates CallState.
This aligns the code with the comment that was already there:
"notification decisions are fully handled by _reconnectController".

Fixes keepalive timeout (4502) appearing as a user-visible error on lock-screen
unlock — it is now silently swallowed and the reconnect proceeds transparently.

* refactor(signaling): log silent reconnect codes in onConnectionFailed

* refactor(signaling): add comment for silent reconnect codes in onConnectionFailed

* refactor(signaling): address Copilot review comments on PR#1089

- SignalingFailureInfo record replaces bare knownCode in onConnectionFailed,
  forwarding systemCode/systemReason so SignalingDisconnectNotification
  retains full diagnostic details
- signalingKeepaliveTimeoutError sets lastSignalingDisconnectCode=null
  to prevent connectIssue UI state (same as controllerForceAttachClose)
- onConnectionFailed uses switch expression for clarity
- fix doc comment example and __onSignalingClientEventDisconnected comment wording

* fix(signaling): reset _wasConnected on app pause to prevent spurious toast on unlock (WT-1221)

When notifyAppPaused disconnects intentionally, _wasConnected is now reset to
false. Previously it stayed true after a successful session, so the first
post-unlock SignalingConnectionFailed hit the '_wasConnected' fast-path and
fired onConnectionFailed immediately — bypassing the consecutive-failure
threshold and showing 'Connecting to the core failed' on screen unlock.

With this fix the post-unlock reconnect is treated as a fresh attempt:
the threshold applies and transient DNS/network failures are suppressed.

* fix(signaling): suppress background notifications and reset state on resume

On Android, SignalingHubModule.connect/disconnect are no-ops — the
foreground-service isolate owns the WebSocket lifecycle and reconnects
independently. When the app is backgrounded, background reconnects set
_wasConnected = true. A subsequent failure then fires onConnectionFailed,
which queues a toast that appears incorrectly when the app resumes
(green status + error toast simultaneously).

Two fixes:
1. Guard _onConnectionFailed calls with (_appActive || _hasActiveCalls).
   No notifications are queued while the user cannot see them.
2. Reset _wasConnected and _consecutiveFailures in notifyAppResumed so
   background reconnect state does not bypass the failure threshold after
   the app comes to foreground.

Adds two new regression tests covering both scenarios.

* docs(signaling): update stale comments in SignalingReconnectController

After PR #1091 SignalingHubModule.connect()/disconnect() are no longer
no-ops — the hub protocol is now bidirectional. Update two comments that
still referenced the old "hub handles reconnects independently" behaviour:

- notifyAppResumed(): explain that the _wasConnected reset is needed for
  persistent-service mode session buffer replay, not for independent
  background reconnects
- _onEvent: replace the outdated no-ops note with the actual reason for
  notification suppression (persistent-mode reconnects while app is closed)

* fix(signaling): preserve _wasConnected during active-call app resume

notifyAppResumed() was unconditionally resetting _wasConnected = false.
When the app resumes during an active call (edge case: brief background),
this caused a subsequent SignalingConnectionFailed to be treated as an
initial connect failure (going through the consecutive-failure threshold)
instead of an established-session drop that notifies immediately.

Fix: only reset _wasConnected when !_hasActiveCalls. The consecutive-
failure counter is still reset unconditionally for a clean retry sequence.

* test(signaling): cover _wasConnected preservation during active-call resume

Add test verifying that notifyAppResumed() does not reset _wasConnected
when _hasActiveCalls is true. Without the fix a SignalingConnectionFailed
after resume during a call would go through the consecutive-failure
threshold instead of notifying immediately (established-session drop).
…t GMS (#1092)

* fix: seed initial push tokens and call state in SessionStatusCubit

On devices without Google Mobile Services (e.g. Huawei), PushTokensBloc
never emits after initialization because GMS availability check returns a
terminal status and aborts token retrieval without emitting any state.

As a result, _lastPushTokensState remained null indefinitely, causing
the condition `pushTokens != null && call != null` in _emitCombinedStatus
to never pass — so the UI was stuck at SessionStatus.inProgress regardless
of the actual signaling/registration state.

Fix: seed _lastPushTokensState and _lastCallState from the blocs' initial
states in the constructor, so subsequent _onCallChanged callbacks can
emit the correct status even when PushTokensBloc never emits.

* refactor: remove unused optional params from _emitCombinedStatus
* fix(signaling): cancel in-flight connect on disconnect via generation counter

disconnect() returned early when _client==null (connect still awaiting
factory), leaving _connecting=true and the in-flight _connectAsync
running. The next connect() was silently dropped, and if the stale
factory eventually resolved, the module set _client and emitted
SignalingConnected even though disconnect() had been called.

Root cause: the guard in disconnect() (`if (client == null) return`)
prevented it from resetting _connecting or invalidating the async task.

Fix: introduce a monotonically increasing _generation counter.
- connect() captures the current generation before starting _connectAsync
- disconnect() increments _generation and resets _connecting=false
- _connectAsync checks its generation at every suspension point; if it
  no longer matches, it cleans up its client without emitting events
- the finally block only resets _connecting for the current generation

Adds four tests reproducing the race: two document the broken behavior
(expected to pass after the fix), two verify correct behavior.

* fix(signaling): add diagnostic logs for cancelled in-flight connect

* refactor(signaling): replace generation counter with Object identity token

* fix(signaling): address Copilot review comments

* fix(signaling): restore library directive to silence dangling doc comment warning
#1095)

* fix(session): investigate stuck connectivityNone status after network restore

* fix(session): clear stale error state when signaling starts reconnecting

When SignalingConnecting fires after a failed connection attempt,
lastSignalingClientConnectError and lastSignalingDisconnectCode were
not cleared. This left the status stuck on connectError/connectIssue
('Connection error' / 'Connection issue') even though a fresh connect
attempt was already in progress.

After this fix, SignalingConnecting resets all stale error fields so
the status correctly shows inProgress ('Connection in progress') for
the duration of the reconnect.
* fix(l10n): escape apostrophe in Italian ARB plural string

ICU message format treats single quotes as escape characters.
Unescaped apostrophe in `dell'ultimo` caused ICU lexing error
and broke gen_localizations during the Android build.

Fix: replace `'` with `''` in the affected plural form.

* fix(l10n): replace ASCII apostrophe with U+2019 in Italian ARB plural

ICU message format treats ASCII ' as an escape character, causing a
lexing error in gen_localizations during CI Android build.

Fix: replace the ASCII apostrophe in `dell'ultimo` with U+2019 RIGHT
SINGLE QUOTATION MARK, which is not an ICU syntax character. Visual
output is unchanged. Regenerated app_localizations_it.g.dart accordingly.
* docs(signaling): document why remoteMessaging is the correct FGS type

SignalingForegroundService maintains a persistent WebSocket to the signaling
server and never accesses microphone, camera, or location. remoteMessaging
is the semantically accurate type for this use case.

Added inline comments in AndroidManifest.xml and SignalingForegroundService.kt
explaining why remoteMessaging is chosen over phoneCall, microphone, or dataSync,
to prevent future misunderstandings during reviews or platform upgrades.

* docs(signaling): tighten remoteMessaging comments — positive rationale only

* docs(signaling): clarify remoteMessaging type is passed not declared conditionally
…n Xiaomi (#1099)

* chore(signaling): umbrella branch for Xiaomi FGS crash fixes

* fix(signaling): call startForeground() first in onCreate() to reduce FGS timeout risk (#1100)

Move startForeground() to be the first call after super.onCreate(), before
any SharedPreferences IPC. Under memory pressure (Xiaomi HyperOS), the
SharedPrefs read in getCallbackDispatcher() can block the main thread for
50-500ms, pushing startForeground() past Xiaomi's aggressive ~1.2s window
and causing ForegroundServiceDidNotStartInTimeException.

startForeground() has no dependency on FlutterEngineHelper or callbackHandle —
the engine starts in onStartCommand(), which runs after onCreate() completes.

* fix(signaling): parallelize Pigeon IPC calls in _startService() to reduce FGS delay (#1101)

* fix(signaling): parallelize Pigeon IPC calls in _startService() to reduce FGS delay

Replace three sequential await calls with Future.wait() so startService()
(which calls startForegroundService()) is dispatched without waiting for
three Binder round-trips first.

All four calls share the same BinaryMessenger, which delivers messages to
the Kotlin main-thread Looper in FIFO order. startService() is always
processed after the credential writes, so synchronizeIsolate() reads
correct data on the first attempt.

Under memory pressure (Xiaomi HyperOS), each sequential Binder round-trip
adds ~100–300 ms. Removing three of them saves ~300–900 ms before
startForegroundService() is called, widening the margin within the
vendor-specific FGS promotion window.

* fix(signaling): parallelize credential writes, keep startService() sequential

Address review concerns:
- Separate credential writes (Future.wait) from startService() (sequential await)
  so startService() is only called after all SharedPreferences writes complete
- Removes the cross-channel FIFO ordering assumption: credentials are
  independent of each other and run concurrently; startService() is
  explicitly sequenced after Future.wait() resolves
- A failure in any credential write now prevents startService() from
  being dispatched, preserving the original error-isolation behavior

Still removes two sequential Binder round-trips (~200–600 ms under
memory pressure) before startForegroundService() is called.

* fix(signaling): persistent mode FGS recovery after OS kill (#1103)

* fix(signaling): persistent mode FGS recovery after OS kill

Part 1 — FCM path: add WebtritSignalingService.restoreService() static
method (Pigeon connect() → Kotlin) called from onPushNotificationSyncCallback
finally block after _disposeContext(). Restores the persistent WebSocket for
future calls when FGS is dead and FCM push is the first trigger.

Part 2 — No-GMS path: add SignalingRestartWorker (WorkManager one-shot)
enqueued from onDestroy (15 s) and onTaskRemoved (1 s) in persistent mode.
Result.retry() handles Android 12+ ForegroundServiceStartNotAllowedException.
SignalingRestartWorker.remove() called first in stopService() to prevent
restart after explicit logout.

Also fixes thread-safety bug: isRunning is now @volatile.

Closes bug-3-no-persistent-mode-recovery.

* fix(signaling): address Copilot review comments on FGS recovery

- cancelUniqueWork instead of cancelAllWorkByTag in SignalingRestartWorker.remove()
- doWork(): retry only on ForegroundServiceStartNotAllowedException, failure() on all others
- doWork(): gate on tenantId/token in addition to coreUrl
- doWork(): Log.e with throwable for full stack trace
- connect() in Plugin: add tenantId/token guards; catch ForegroundServiceStartNotAllowedException
  natively and enqueue WorkManager instead of propagating to Dart
- onDestroy/onTaskRemoved: gate WorkManager enqueue on coreUrl+dispatcher to skip after logout
- background_isolate_callbacks: fix comment (exception handled natively), pass (e, st) to logger
- Add unit tests: plugin_test restoreService channel wiring, signaling_service_test delegation

* fix(signaling): address second round Copilot review comments

- SignalingRestartWorker: update KDoc to reflect actual retry logic
  (retry only on ForegroundServiceStartNotAllowedException, failure() on others)
- SignalingRestartWorker: Log.w for transient ForegroundServiceStartNotAllowedException,
  Log.e only for permanent failures — reduces noise in normal retry scenarios
- SignalingForegroundService.onDestroy: add tenantId/token guards to align with
  connect() and doWork() credential checks
- SignalingForegroundService.onTaskRemoved: same tenantId/token guard alignment

* fix(signaling): remove direct API-31 class reference for minSdk-26 safety

Replace `is ForegroundServiceStartNotAllowedException` checks with
javaClass.name string comparison in WebtritSignalingServicePlugin and
SignalingRestartWorker. Removes direct import of the API-31+ framework
class, eliminating any risk of class-verification errors on pre-31 ART.
Also removes the now-redundant Build.VERSION.SDK_INT guard and imports.

Remove stale inline comment from background_isolate_callbacks.dart.

* fix(signaling): restore idiomatic API-31 exception check via @RequiresApi helper

javaClass.name string comparison is fragile, not idiomatic, and misses
subclasses. Replace with a @RequiresApi(S) helper that holds the `is`
check in API-guarded code, satisfying Lint while keeping type safety.
The outer Build.VERSION.SDK_INT >= S guard means the helper is never
called on pre-31 devices, so no class-loading risk exists.

* fix: push isolate reuses FGS hub WebSocket (1-socket invariant) (#1104)

* fix: push isolate reuses FGS hub WebSocket instead of opening its own

PushNotificationIsolateManager previously always created a SignalingModuleImpl
directly, opening a second WebSocket when the FGS hub was already running.
This violated the 1-WebSocket invariant.

The fix adds createPushIsolateModule() to SignalingServicePlatform and
WebtritSignalingService. On Android, the method checks IsolateNameServer for
a live FGS hub: if found and acknowledged, a SignalingHubModule is returned
and no new connection is opened. When no hub is active (app killed), falls
back to a direct SignalingModuleImpl.

PushNotificationIsolateManager now exposes an init() method for async hub
discovery, called from _getOrInit() before run(). run() skips connect() when
the module reports isConnected (hub reuse path).

* logs: add diagnostic logs for push isolate signaling module resolution

Adds log lines to verify the 1-socket invariant at runtime:
- _initSignaling(): logs module type and isConnected after createPushIsolateModule
  resolves, making hub-reuse vs direct-socket path visible in logcat
- close(): logs module type and pending request count on teardown
- _getOrInit(): logs before and after init() so init timing is traceable

These logs confirm whether the hub path or fallback path is taken on each push
notification, and that teardown completes cleanly.

* fix: push isolate starts FGS and waits for hub instead of direct socket

Previously createPushIsolateModule fell back immediately to a direct
SignalingModuleImpl when no hub was running (pushBound mode, first push
after app close). This violated the 1-WebSocket invariant: both the push
isolate and the Activity would open their own WebSocket connections.

New flow:
1. Hub already running -> reuse (persistent mode or second push)
2. No hub -> startFgsOnly(pushBound) -> poll IsolateNameServer until hub
   registers and acknowledges -> return SignalingHubModule
3. Hub not available after 10 s -> emergency fallback to direct module

The push isolate now always shares the same FGS WebSocket with the
Activity, matching the intended architecture.

* fix: address Copilot review comments on push isolate hub reuse

- _tryConnectHub: yield to event loop after ack so hub replay events
  (SignalingConnected etc.) are processed before returning the module.
  Prevents run() from calling connect() on an already-live hub session
  due to isConnected being transiently false while replay is in flight.

- run(): throw StateError when called before init() instead of silently
  leaving _completer unresolved and hanging the caller until timeout.

- lefthook.yml: pass {1} to commit-msg-check.sh so the hook reads the
  actual commit message file (git hook argv) instead of git log -1.

* refactor: unify push isolate signaling with Activity via WebtritSignalingService

Push isolate now uses WebtritSignalingService(mode: pushBound) directly,
the same mechanism the Activity uses. HubConnectionManager inside the
service handles FGS start, hub discovery, and auto-reconnect if the hub
is killed between push arrival and Activity open.

Removes createPushIsolateModule from platform API — it was a one-shot
workaround that bypassed HubConnectionManager and had no reconnect logic.
Removes _tryConnectHub, _startFgsOnly, and related constants from the
Android plugin. The single code path eliminates the duplication.

Lifecycle boundary remains unchanged:
  push mode  — FGS lives from push arrival through Activity close
  persistent — FGS lives indefinitely

* fix: address remaining Copilot review comments

- Remove unnecessary async from _initSignaling() — nothing is awaited
- Rename log field hubConnected -> isConnected (accurate with WebtritSignalingService)
- Always call connect() in run() — WebtritSignalingService.connect() is
  idempotent via _startPending/_isConnected guard; the old conditional
  was left over from SignalingHubModule direct usage
- Fix class doc: qualify 1-WebSocket claim as Android-only
- Fix _initSignaling doc: remove auto-reconnect claim, HubConnectionManager
  handles FGS start and hub discovery, not reconnect after disconnect
- commit-msg-check.sh: add guard for missing or unreadable $1 argument

* docs: fix stale comments after signaling unification

- init() doc: hub discovery and FGS start happen in connect() via
  HubConnectionManager, not in init() as the old comment stated
- background_isolate_callbacks: same correction for _getOrInit comment
- commit-msg-check.sh: add set -euo pipefail for consistency with other scripts

* refactor: replace nullable signalingModule with late field and remove unnecessary async from init()

- _signalingModule/_signalingSubscription: SignalingModule? → late SignalingModule;
  eliminates null-aware ?. access throughout the class since run() requires init() first
- Add _initialized bool flag so close() can guard disposal without accessing late fields prematurely
- init(): Future<void> async => _initSignaling() → void init() (sync, no await needed)
- run(): null check replaced with !_initialized guard; remove intermediate `module` local
- All ?. accesses on _signalingModule replaced with direct . access
…1118)

* feat(signaling-service): add simulateKill API for service-restart QA

Adds WebtritSignalingService.simulateKill() — stops the Android foreground
service immediately without a graceful WebSocket disconnect, preserving
SharedPreferences credentials so WorkManager and START_STICKY can restart
the service via the same recovery path triggered by a real OS kill.

- pigeons/signaling.messages.dart: add simulateKill() to PSignalingServiceHostApi
- messages.g.dart / Messages.g.kt: regenerated by Pigeon
- SignalingForegroundService.kt: simulateKill() calls stopSelf() directly
- WebtritSignalingServicePlugin.kt: delegate to SignalingForegroundService
- Android/iOS/platform-interface Dart plugins: implement / no-op override
- WebtritSignalingService: expose static simulateKill()

* feat(dev-tools): add hidden dev tools screen via 15-tap on about logo

Adds a secret diagnostic screen accessible by tapping the app logo 15
times on the About screen. The screen exposes service-level actions for
QA and manual testing that should not appear in the normal settings UI.

- dev_tools feature: DevToolsScreen + DevToolsScreenPage (@RoutePage)
- About screen: 15-tap MultiTapTrigger on the logo navigates to dev-tools;
  added proper dispose for both triggers
- Router: dev-tools route registered under settings
- l10n: devTools_* keys added to app_en.arb and regenerated
- build_runner: app_router.gr.dart regenerated with DevToolsScreenPageRoute

First action: Simulate service kill (calls WebtritSignalingService.simulateKill)

* fix(dev-tools): correct import grouping in dev_tools_screen

Separate external (auto_route) and internal monorepo packages
(webtrit_signaling_service) into distinct import groups per project
code standards. Replace relative ../../../ import with package: path.

* fix(dev-tools): address Copilot review comments

- Extract multi-statement onPressed into _onSimulateKillConfirmed() per
  single-expression callback rule; wrap simulateKill() with unawaited()
- Revert push-bound guard in simulateKill: OS kills the service in all
  modes, so the API should work regardless of current mode
…lly (#1108)

When Android kills the FGS background isolate (e.g. Samsung battery
optimisation), the main isolate's ReceivePort goes silent with no
onDone or error. HubConnectionManager._module stays non-null (stale
object), so begin() returns immediately on the _module != null guard
and hub rediscovery never runs. Status is stuck at "Connection in
progress" indefinitely.

Fix adds two cooperating changes:

1. Liveness ping in SignalingHubClient
   After subscribe, a periodic timer fires every pingInterval (default 15 s).
   A SignalingHubPingCommand is sent to the hub; the hub replies with a
   pong. If no pong arrives within pongTimeout (default 2 s) the client
   closes its event StreamController, signalling hub death downstream.
   Both intervals are configurable via HubConnectionManager constructor
   parameters so they can be tightened in tests.

2. onDone cascade to HubConnectionManager
   SignalingHubModule now propagates _hubClient.events onDone to its
   own _controller. HubConnectionManager._moduleSub adds an onDone
   handler that nulls _module and restarts begin(), which polls
   IsolateNameServer every 100 ms until the WorkManager-restarted FGS
   registers a fresh hub port.

Recovery path after fix:
  hub dies → ping timeout (≤17 s) → _controller.close()
  → SignalingHubModule._onHubDone → module _controller.close()
  → HubConnectionManager._moduleSub.onDone → _module = null → begin()
  → _initLoop finds new hub port after WorkManager restarts FGS (~15 s)
  → SignalingConnected → CallStatus.ready

Files changed:
  signaling_hub_command.dart  — add SignalingHubPingCommand
  signaling_hub_codec.dart    — add encodePong / isPong helpers
  signaling_hub.dart          — handle ping → send pong
  signaling_hub_client.dart   — periodic ping timer, pong handling, configurable intervals
  signaling_hub_module.dart   — propagate onDone from client to module controller
  hub_connection_manager.dart — onDone handler on _moduleSub; pingInterval/pongTimeout params
… is alive (#1119)

* fix(signaling-service): recover from hub service death while Activity is alive

Three problems exposed by simulateKill in push-bound mode:

1. Stale IsolateNameServer port: when the foreground service dies abruptly
   (OS kill or simulateKill), the background isolate's SignalingHub port
   stays registered forever. HubConnectionManager._initLoop found the port
   on every retry and timed out on ack indefinitely.

2. No service restart in push-bound mode: onDestroy only enqueues
   SignalingRestartWorker for persistent mode. When the Activity is alive
   and the service dies, nobody called startService() again.

3. Stuck connected indicator: SignalingConnectionFailed / SignalingDisconnected
   were never emitted so CallBloc kept the stale connected state.

Fix - HubConnectionManager:
- Track consecutiveStaleAcks per _initLoop run.
- After stalePortThreshold (default 3) consecutive stale ack timeouts:
    * Remove the dead port from IsolateNameServer so polling resumes cleanly.
    * Emit SignalingConnectionFailed so the UI and CallBloc see the disconnect.
    * Call onServiceDead() callback (if set) to restart the service.
- Reset counter when port absent (normal polling) or on successful ack.

Fix - WebtritSignalingServiceAndroid:
- Wire onServiceDead: _onHubServiceDead into HubConnectionManager.
- _onHubServiceDead restarts the foreground service via _startService(),
  works for both push-bound and persistent modes.

Recovery path after kill (push-bound, Activity alive):
  hub ping/pong timeout (~17 s after kill)
  → 3 consecutive stale acks (~1.5 s)
  → port cleared, SignalingConnectionFailed emitted, _startService called
  → new FGS starts, new isolate, new hub port
  → _initLoop reconnects, stream resumes

* fix(signaling-service): suppress NotConnectedException during hub reconnect window

During the ~1-2 s between hub death and SignalingConnectionFailed arriving,
WebtritSignalingService._isConnected is still true so executeNow() is called.
The plugin.execute() throws because _hubManager.isConnected is false.

Previously this was a raw StateError which bypassed the retry logic entirely
and propagated directly to runZonedGuarded → Firebase Crashlytics, even
though the service was already restarting in a controlled way.

- plugin.dart: throw NotConnectedException instead of StateError so the error
  is typed and the request queue can handle it gracefully.

- signaling_request_queue._executeWithRetry: catch NotConnectedException and
  back off 2 s before each retry (up to maxRetryCount times). The isActive()
  closure evaluates _isConnected live — once SignalingConnectionFailed sets it
  to false, the retry loop exits silently. Only if the service is still dead
  after maxRetryCount retries does the exception propagate to Firebase.

* fix(signaling-service): address Copilot review comments

hub_connection_manager:
- Guard the stale-port-threshold block with a generation / isActive /
  tearingDown check so tearDown() or a concurrent begin() cannot trigger
  side effects (port removal, SignalingConnectionFailed, onServiceDead)
  after the loop has already been superseded.
- Replace hardcoded Duration(seconds: 3) with kSignalingClientReconnectDelay.

plugin:
- Wrap onServiceDead callback with unawaited() to make the fire-and-forget
  intent explicit and keep the void Function() signature satisfied.
- Add try/catch in _onHubServiceDead so errors from _startService are
  logged as SEVERE rather than silently dropped.

signaling_request_queue:
- _executeWithRetry: rethrow NotConnectedException when !isActive() so that
  flush() correctly leaves the request in the queue (via its own !isActive()
  guard) instead of completing it as a false success.
- executeNow: wrap _executeWithRetry with a NotConnectedException catch that
  silently drops the error when !isActive(), preserving the reconnect-window
  suppression behaviour without affecting flush() semantics.
…nstalls (#1109)

AGP 8.x silently injects extractNativeLibs=false as the default, causing
bundletool to store .so files uncompressed (Stored/method=0) in the
universal APK. On non-certified Android devices (Huawei without GMS,
custom ROMs) the package manager does not properly support mmap-from-APK,
leaving lib/ empty at runtime — resulting in a MissingLibraryException
for libflutter.so on every launch.

Setting extractNativeLibs=true overrides the AGP default and causes the
installer to always extract native libs to disk during installation,
which works on all Android variants regardless of GMS certification.

Fixes: WT-1019
Affects: all white-label builds distributed outside Google Play
…T-1039) (#1112)

* fix(signaling): silence controllerUnknownError (4400) on reconnect (WT-1039)

After multi-day inactivity or multi-device use, the server may emit code 4400
(controllerUnknownError) on the first reconnect attempt due to stale controller
state. The subsequent reconnect always succeeds; suppress both the user-facing
notification and the transient connectIssue UI state for this code, consistent
with how controllerForceAttachClose (4441) is handled.

* docs(signaling): add root cause explanation for controllerUnknownError silent reconnect

* refactor(call_bloc): split silent-reconnect cases and add reason to log
…(WT-1080) (#1106)

* fix(call): [WT-1080] placeholder — implement incoming call hangup when signaling unavailable

* fix(call): send decline when incoming call registration fails (WT-1080)

Path 1 (_onCallPushEventIncoming): iOS CXProvider may reject incoming call
registration before the user sees it (DND, blocklist, unentitled). Since
signaling is disconnected at push-receive time we cannot decline immediately.
The server replays the incoming event on next handshake reconnect, which
re-enters Path 2 where the SIP line is now known. Replaced the silent drop
with a descriptive log and explanation of the recovery path.

Path 2 (__onCallSignalingEventIncoming): when reportNewIncomingCall returns
an unexpected error (callRejectedBySystem on Android, unknown/internal on iOS)
send a DeclineRequest immediately. The call was never shown to the user so
performEndCall will not fire. _signalingModule.execute returns null when
disconnected — the ?. operator handles that safely.
…-1186) (#1111)

* fix(ios): add 8s timeout to getUserMedia in incoming call handler (WT-1186)

getUserMedia is called inside the PushKit 30s OS deadline window during
incoming call answer. If the camera driver hangs or AVFoundation blocks,
the entire budget is consumed and iOS silently kills the app — the call
is dropped with no error logged.

Add an 8s timeout to the userMediaBuilder.build() call in
__onCallPerformEventAnswered(). If exceeded, a warning is logged and
TimeoutException is thrown so the existing error handler can decline
the call gracefully instead of letting the OS kill the process.

allowAudioFallback: true remains in place to handle permission denials
before the timeout path is reached.

* refactor(call_bloc): extract getUserMedia timeout handler to private method

Extract the onTimeout callback into a private _onGetUserMediaPushKitTimeout()
method and the 8s duration into a top-level const, per single-expression
callback convention. Addresses Copilot review comment on PR #1111.
…46) (#1110)

* fix(signaling): guard Transaction against double-complete race (WT-1046)

Add _isDone flag and _finish() helper to Transaction so that all three
terminal paths (handleResponse, terminateByDisconnect, _onTimeout) are
symmetric. Previously only _onTimeout had the isCompleted guard; a late
server response arriving after a timeout could call _completer.complete()
on an already-completed Completer, throwing StateError and cascading into
WebtritSignalingTransactionTimeoutException for all ICE transactions.

Add unit tests covering all terminal paths and race scenarios using
fake_async for deterministic timer control.

* fix(signaling): log warning on duplicate Transaction completion attempt

* fix(signaling): address Copilot review — update docs and wrap catchError with unawaited
CallerIdSettingsRepository.create and another provider create: in main_shell.dart
call getLocalSystemInfo() synchronously. If system info is absent (cleared during
session cleanup after FGS failure, or never fetched in this session) the provider
throws StateError and crashes the app.

Add a getSystemInfo(cacheFirst) call to onMainShellRouteGuardNavigation, executed
after the auth check and before navigation resolves. If system info cannot be
loaded — no cache and network unreachable — redirect to the login screen.

This prevents the crash observed on Xiaomi after FGS recovery:
  CallerIdSettingsRepository.create → getLocalSystemInfo() → StateError:
  "No system info in cache" (main_shell.dart:257)
* fix(WT-1083): use processingStatus to guard outgoing call reconciliation

Instead of skipping all unacknowledged outgoing calls during StateHandshake
reconciliation, skip only those where OutgoingCallRequest was not yet sent.
Adds isPreOfferSent getter to CallProcessingStatus enum with an explicit
allowlist of pre-send statuses. Calls that have passed outgoingOfferSent
but are absent from the handshake are treated as dead and force-terminated.

* refactor(WT-1083): move handshake reconciliation logic to CallState

Extract callsToTerminate(List<String> activeLineCallIds) into CallState so
the logic is testable without BLoC dependencies. CallBloc converts signaling
Line objects to callId strings before delegating to the method.

Add unit tests covering: skip in-flight outgoing, terminate post-offer
outgoing, keep calls present in handshake lines.

* perf(WT-1083): use Set<String> for handshake line lookup in callsToTerminate

Reduces membership checks from O(n) to O(1) per active call.

* refactor(WT-1083): return List<ActiveCall> from callsToTerminate, drop loop label

- callsToTerminate now returns List<ActiveCall> instead of List<String>,
  removing the redundant retrieveActiveCall lookup in _handleHandshakeReceived
- Remove activeCallsLoop label — no nested loops, plain continue is sufficient
- Update test assertions to compare ActiveCall objects directly

* fix(WT-1083): cancel pending OutgoingCallRequest when call is hung up (#1113)

* fix(WT-1083): cancel queued OutgoingCallRequest on hangup

Add `cancelByCallId` to `SignalingRequestQueue` and expose it as
`cancelRequestsByCallId` on the `SignalingModule` interface.

Call it at the start of `__onCallControlEventEnded` so that when the
user presses hangup while offline (OutgoingCallRequest still waiting in
the queue), the request is dropped immediately rather than being sent
on the next reconnect.

Without this fix:
- The 30-second queue timeout blocked `__onCallPerformEventEnded` via
  the sequential transformer, delaying ringback stop and PeerConnection
  disposal by up to 30 seconds.
- On internet restore before the timeout, flush() sent
  OutgoingCallRequest before HangupRequest, causing the callee to
  briefly see a phantom incoming call.

`SignalingHubModule.cancelRequestsByCallId` is a no-op — the hub module
routes requests directly without a local queue.

* fix(WT-1083): implement cancelRequestsByCallId in all SignalingModule impls

Add no-op `cancelRequestsByCallId` to all `SignalingModule` implementations
and test fakes that were missing the new interface method:

- WebtritSignalingService (delegates to _requestQueue.cancelByCallId)
- SignalingHubModule (no-op — hub routes directly, no local queue)
- Integration test fakes in signaling_service_android and _ios
- _FakeSignalingModule in signaling_reconnect_controller_test

* fix(signaling-queue): guard flush() against concurrent cancelByCallId race

Replace removeFirst() with remove(entry) in both branches of flush() so
that a concurrent cancelByCallId() call that removes the entry during an
await suspension does not accidentally pop the next unrelated entry.

Add unit tests covering: immediate NotConnectedException on cancel,
cancelled requests skipped by flush, cross-call isolation, and the
identity-based removal guard under concurrent cancel.

* fix(WT-1083): skip disconnecting calls on reconnect and retry lost HangupRequest (#1114)

* fix(WT-1083): skip disconnecting calls on reconnect and retry lost HangupRequest

Bug A: _safeRenegotiate now skips calls in disconnecting status when
signaling reconnects. Previously UpdateRequest was sent for dying calls,
keeping the server-side leg alive and causing the callee phone to keep
ringing after the local user pressed hangup.

Bug B: _handleHandshakeReceived now retries HangupRequest for any call
that is locally disconnecting but still present in the server handshake
lines. This covers the case where the hangup was lost while signaling
was offline and the request never reached the server.

* fix(WT-1083): add .ignore() to fire-and-forget HangupRequest retry

Make the unawaited intent explicit and consistent with the existing
pattern in the file (e.g. _signalingModule.execute(...)?.ignore()).

* fix(WT-1083): hang up orphaned outgoing call when connection and BLoC state are both absent (#1115)

* fix(WT-1083): hang up orphaned outgoing call when connection and BLoC state are both absent

When the user hangs up an outgoing call while offline, performEndCall removes
the call from both BLoC activeCalls and CallKeep. If the HangupRequest was lost
offline, the server-side leg stays alive indefinitely — HandshakeProcessor had
no branch for the case where connection == null and the call is not in activeCallIds.

Add an else-if branch after the stateDisconnected check: if the CallKeep
connection is null, the call is absent from BLoC activeCallIds, the latest
server event is not incoming or terminal, and no AcceptedEvent exists in the
call log — emit HangupSignalingAction to tear down the orphaned server leg.

Guards:
- AcceptedEvent check: preserves app-restart restoration for accepted calls
  (where connection is also null but a RestoreCallAction should be generated)
- activeCallIds check: on iOS getConnection() always returns null; this guard
  prevents hanging up calls that are still active in BLoC

Add 5 unit tests covering the new branch and each guard condition.

* refactor(WT-1083): hoist callEventLogEntries above connection block to avoid re-scan

Move callEventLogEntries, earliestCallEvent, and acceptedLogEntry
computation before the getConnection() block so the orphaned-call guard
can reuse acceptedLogEntry == null instead of re-traversing callLogs.
Replace the now-redundant latestCallEvent alias with the already-computed
callEvent in the isTerminated check.

* fix(WT-1083): keep outgoing call alive for 30s while signaling reconnects (#1116)

* fix(WT-1083): wait full 30s for signaling reconnect before ending outgoing call

Removes the early-exit on signalingFailed in __onCallPerformEventStarted.
Previously the firstWhere returned on the first SignalingConnectionFailed
(~300ms), triggering callkeep.endCall() before the reconnect controller
had a chance to recover.

Now the wait only exits on signalingReady or after the new
kOutgoingCallSignalingWaitTimeout (30s), giving the reconnect controller
the full window to restore connectivity while the call stays in DIALING.

* fix(WT-1083): skip EndLocalCallAction for BLoC-managed calls in HandshakeProcessor

Loop C was generating EndLocalCallAction for any Callkeep connection absent
from the server's handshake lines, including outgoing calls that the BLoC is
actively managing but has not yet sent an OutgoingCallRequest for.

This caused the call to be torn down via callkeep.endCall() the moment the
first handshake arrived after reconnect, while __onCallPerformEventStarted
was concurrently preparing the SDP offer.

Guard: skip connections whose callId is in activeCallIds — those calls are
managed by the BLoC and are not stale orphans.

* fix(WT-1083): suppress reconnect snackbar when active call is on screen

The call screen already shows "No internet connection / Connecting to the
remote server" during a signaling reconnect, so the SignalingConnectFailed
snackbar is redundant and clutters the UI.

Skip the notification in onConnectionFailed when state.isActive is true.

* refactor(WT-1083): address Copilot review comments

- constants.dart: use platform-agnostic wording in kOutgoingCallSignalingWaitTimeout doc
- call_bloc.dart: update stale comment referencing old timeout constant
- call_bloc.dart: extract onConnectionFailed closure to _handleConnectionFailed method
- call_bloc.dart: remove inline comments added in previous commit
- handshake_processor.dart: remove inline comment added in previous commit

* fix(WT-1083): exit signaling wait immediately when user hangs up (#1117)

* fix(WT-1083): exit signaling wait immediately when user hangs up

When the user pressed hangup while __onCallPerformEventStarted was blocked
in firstWhere waiting for signaling (outgoingConnectingToSignaling), the
await would continue for the full 30s timeout before the call screen closed.

Two changes:
1. firstWhere now also exits when the call leaves outgoingConnectingToSignaling
   (processingStatus changed or call removed) so a user hangup unblocks the
   await immediately.
2. The "not connected" block checks whether the call is still in
   outgoingConnectingToSignaling before performing endCall/notification.
   If the hangup flow already took over, only event.fail() is called to
   avoid a double-end and a spurious CallWhileOfflineNotification.

* fix(signaling): prevent post-cancel enqueue blocking in SignalingRequestQueue

cancelByCallId now marks the callId as terminating so any subsequent
enqueue for the same callId is rejected immediately instead of waiting
up to 30 s for a queue timeout.

This fixes the 8.5 s call screen freeze after pressing hangup while
offline: __onCallControlEventEnded calls cancelRequestsByCallId before
the HangupRequest is created, so the new request was not covered by the
earlier cancel and blocked the __onCallPerformEventEnded handler.

failAll clears the terminating set so a fresh session can reuse the
same callId. Three unit tests added to signaling_request_queue_test.dart.

* refactor(WT-1083): address Copilot review comments on PR #1117

- Extract firstWhere predicate to _shouldExitOutgoingSignalingWait private
  method (comments #1 and #4 — multi-statement callback)
- Fix DartDoc of _terminatingCallIds: remove non-existent [performControlEnd]
  reference, replace with plain-text description (comment #2)
- Add removeTerminatingMark(callId) to SignalingRequestQueue and expose as
  clearTerminatingMark on SignalingModule interface; call it in
  __onCallPerformEventEnded via try/finally to bound the set size instead of
  keeping entries until failAll (comment #3); implement in all four
  SignalingModule implementors
- Add removeTerminatingMark unit test to signaling_request_queue_test.dart

* test: add clearTerminatingMark no-op to SignalingModule test fakes

* fix(call): address Copilot review comments on PR #1094

- handshake_processor: use earliestCallEvent (not callEvent/latest) in
  the orphan outgoing-call guard so incoming calls that have received
  ProceedingEvent/RingingEvent are not mistakenly hung up
- signaling_hub_module: remove leftover DEBUG _logger.info from execute()
- call_bloc: fix nullable Future — chain ?.ignore() instead of .ignore()
  to avoid a potential NoSuchMethodError on a null result
After `?.catchError(...)`, Dart short-circuits the chain if execute() returns
null, so the receiver of `.ignore()` is never null at that point. Replace
`?.ignore()` with `.ignore()` to resolve the invalid_null_aware_operator warning.
…al (#1122)

* fix(push_tokens): prevent crash when token retrieval completes after bloc teardown

Token retrieval for FCM and APNS is started as an unawaited async operation
and can outlive the bloc lifecycle. On logout the bloc is closed while a token
fetch may still be in flight. When the fetch completes it tries to dispatch
an event to an already-closed bloc, causing a StateError crash. Added a
lifecycle check before dispatching to prevent this.

* fix(push_tokens): address Copilot review — missing guard, misleading logs, regression test

- Guard add() in APNS shouldRetry callback against closed bloc
- Replace inaccurate 'failed after max attempts' log with 'completed without a token'
- Expose FCM and APNS retrieval methods as @VisibleForTesting
- Add regression tests covering token arrival after bloc close for both FCM and APNS paths

* fix(push_tokens): remove unused import in test file

* fix(push_tokens): remove unnecessary imports flagged by analyzer
…ushBound mode (#1130)

* fix(signaling-service): stop orphaned foreground service in pushBound mode

In pushBound mode the foreground service was expected to live only while
the Activity is connected. When a call is declined before the Activity
launches (e.g. from lock-screen, or due to the auto-decline bug), the
Activity never subscribes to SignalingHub, hasSubscribers stays false, and
the service runs indefinitely - behaving like an unintended persistent mode.

Changes:
- Add mode field to PSignalingServiceStatus (Pigeon Dart + Kotlin) so the
  background Dart isolate knows the service mode without reading SharedPrefs
- Pass mode = isPushBound ? PUSH_BOUND : PERSISTENT in synchronizeIsolate()
- Add SignalingHub.onHasSubscribersChanged callback, fired on 0 <-> 1
  transitions so the manager reacts without polling
- Add SignalingForegroundIsolateManager.isPushBound field; when true, wire
  the hub callback to schedule a 30s cleanup timer on last-unsubscribe and
  cancel it on next-subscribe
- When the timer fires (no subscriber for 30s in pushBound mode), call
  PSignalingServiceHostApi().stopService() to stop the orphaned service
  (START_NOT_STICKY prevents OS restart)
- Thread isPushBound through signaling_sync_handler.dart constructor and
  configChanged check

* refactor(signaling-service): make pushBound grace period configurable

Change timeout from 30s to 10s -- 30s had no concrete justification,
Activity subscribes within 1-3s on any device so 10s is sufficient
headroom. Expose as constructor parameter pushBoundNoSubscriberGrace
(default 10s) so tests can pass a short duration without sleeping and
callers can tune it if needed.

* test(pushBound): cover cleanup-timer lifecycle in isolate manager

- Extended _FakeSignalingHub with onHasSubscribersChanged + simulateSubscriberChange
  so tests can drive subscriber transitions without IsolateNameServer.
- Added @VisibleForTesting stopServiceOverride to SignalingForegroundIsolateManager
  so unit tests can intercept _requestServiceStop() without binary messenger.
- Fixed _start(): schedule cleanup timer immediately when isPushBound and hub
  has no subscribers at start time (initial push-started state, Activity not
  yet connected -- the timer was previously never scheduled in this case).
- Added 5 tests covering: no-subscriber grace-period stop, subscriber arriving
  within grace cancels timer, last-subscriber-leave re-schedules timer, explicit
  stop() cancels timer, persistent mode never schedules timer.

* fix(lint): promote _testStopService to local before null check

* fix(signaling-service): address Copilot review comments on PR #1129

- SignalingHub._handleUnsubscribe: guard with wasNotEmpty so
  onHasSubscribersChanged(false) fires only on real 1→0 transitions,
  not spurious removes of unknown consumers when already empty
- _requestServiceStop: wrap PSignalingServiceHostApi().stopService()
  with unawaited+catchError to handle platform channel failures explicitly
- Fix doc comment: _pushBoundNoSubscriberGrace → pushBoundNoSubscriberGrace
- pigeons/signaling.messages.dart: add required this.mode to constructor
  to keep the Pigeon input file consistent with the generated output

* refactor(pushBound): remove redundant initial hasSubscribers check at start

The push-notification (FSM) isolate always subscribes to the hub before
the Activity connects, so the cleanup timer should start from the 1→0
transition (push isolate leaves), not from service start.

Removing the initial check in _start() means:
- no spurious timer that gets immediately cancelled on every push
- the 10s grace window begins at the right moment (push isolate left,
  Activity has not yet connected)

Updated tests to reflect the real flow: push isolate subscribes first,
then unsubscribes, then Activity connects or doesn't.

* docs(pushBound): document FGS lifetime after push callback completes
SERDUN added 30 commits June 9, 2026 14:45
…24) (#1371)

The recents list and the contact opened from it ("View Contact") joined a
call-log number to a contact with no source tie-break, so when a number was
shared by a local and an external (PBX) contact the winner was left to SQLite's
unspecified row order (the first row kept by _rowsToRecent). That let the list
and the opened contact card show a different source than the call screen, which
reads as mixed name/avatar.

watchLastRecents and getRecentByCallId now order the joined rows by
contactsTable.sourcePriorityOrder() so the external contact is kept
deterministically. watchLastRecents keeps createdAt/hungUpAt as the primary
ordering (orderBy on a joined statement replaces the term list, so the full list
is passed) and applies the source priority only as a per-call-log tie-break.

Covered by recents_dao_test (both insertion orders, list order preserved,
non-colliding numbers unaffected).
…8) (#1372)

* feat: surface call network quality from Janus slowlink events (WT-1008)

Handle the IceSlowLinkEvent that Janus emits (and Core already forwards) before
a hard ICE failure, and show a low-footprint signal meter beside the call timer.

- CallBloc: new IceSlowLinkEvent branch resolves the active call by line and
  drives slowlinkDetected/Cleared/Hidden mutations; a DebounceMap auto-hides the
  indicator once events stop, with a brief "recovered" confirmation first.
- ActiveCall gains a transient networkQuality field, mirroring iceConnectionIssue.
- New CallNetworkQuality model (severity/uplink/media/recovered) with a pure,
  unit-tested severity heuristic over slowlink frequency and packet loss.
- CallNetworkQualityMeter widget: icon-only signal bars + direction arrow +
  audio/video glyph, severe label only at severe; coral stays reserved for
  failures. Rendered beside the timer in CallInfo, suppressed during a real
  iceConnectionIssue.
- l10n: callNetworkQuality_* keys in en/it/uk.

* feat: animate call network-quality meter transitions

Wrap the meter beside the call timer in an AnimatedSwitcher (fade + horizontal
size) keyed by the full meter signature, so appearing/disappearing and every
severity/direction/recovered change cross-fade smoothly instead of popping.
Wrapped in a RepaintBoundary to isolate the animation repaints.

* fix: keep call timer centered, move network-quality meter to its own line

The meter sat beside the timer, so its width changes (and the wide severe
label) reflowed the FittedBox'd call header and jolted the centered timer
sideways. Move the meter to a fixed-size (60x18) reserved slot on its own line
directly below the timer: the call header footprint is now constant across all
states, so the FittedBox never rescales and the timer never shifts. The meter
is icon-only (bars + direction arrow + audio/video glyph); the descriptive
label moves to a Semantics label instead of taking horizontal space.

* fix: show network-quality label on its own centered line below the timer

Restore the visible severe label (it was moved to a Semantics-only label when
the meter was made icon-only). The meter now sits on its own centered line
directly below the timer rather than beside it, so its width -- including the
label -- never pushes the centered timer sideways. It fades and grows in/out
with a vertical SizeTransition.

* fix: morph network-quality meter changes and source its colors from theme

Two polish fixes for the slowlink meter:
- State changes (severity / direction / recovered) are morphed in place -- bar
  colors tween via AnimatedContainer, the row resizes via AnimatedSize -- instead
  of cross-fading the whole widget, which flickered. The parent AnimatedSwitcher
  is now keyed by presence only, so it fades only on show/hide.
- Severity colors come from the theme's semantic SnackBarStyles palette
  (warning = degrading, success = recovered) with ColorScheme fallbacks, instead
  of hardcoded hex constants. Coral/error stays reserved for real failures.

* style: address Copilot review nits on the network-quality meter

- Derive the severe label TextStyle from textTheme.labelMedium instead of a raw
  TextStyle literal.
- Separate the Flutter SDK import from package imports in the model test (6-group
  rule).
- Clarify the severeLabel doc: it is always the Semantics label, shown as visible
  text only at severe.
(The hardcoded-color note was already resolved by sourcing colors from the theme.)
* fix(app_database): clear drift codegen warnings

- bump drift/drift_dev floor to ^2.32.1 (resolves the references() "simple
  class name" false positive seen on drift_dev 2.31.0 + analyzer 10.2.0;
  fixed upstream in drift_dev 2.32.1). Now resolves drift 2.33.0 and
  sqlparser 0.44.4.
- add the referenced-table imports so drift can resolve the customConstraint
  foreign keys in contact_emails and favorites (matches contact_phones).
- drop the redundant UNIQUE on cdrs.call_id (it is already the primary key)
  and recreate the table via schema migration v23, with data-integrity tests.

Codegen now runs with 0 warnings, analyze is clean, and migration tests pass.

* refactor(app_database): drop unnecessary foreign_keys toggle in cdrs v23 migration

cdrs has no inbound foreign keys, and onUpgrade already runs migrations with
foreign_keys disabled, so the per-migration PRAGMA toggle is redundant and the
trailing re-enable coupled correctness to the migration reaching that line.
* feat(login): enforce deterministic login options order

The login tabs (OTP vs Password) were rendered in whatever order the
backend /api/v1/system-info adapter.supported[] array returned, which is
not stable across requests, so the tab order and the default selected tab
could change between logins.

Impose a deterministic client-side order via sortLoginTypes(): the regular
password login is first by default (faster for frequent users; OTP delivery
takes a moment anyway). The order is overridable at build time through the
WEBTRIT_APP_LOGIN_TYPES_ORDER dart-define for per-flavor needs. Backend
supported[] now only decides which options are shown, not their order.

WT-1464

* refactor(login): drive sign-in order from app config instead of env

Move the login tab order knob out of the build-time dart-define and into
the appearance/app config so it can be managed per application from the
configurator, alongside the existing bottom-menu and settings ordering.

- webtrit_appearance_theme: add AppConfigLogin.signinOrder (List<String> of
  login type names), default passwordSignin, otpSignin, signup.
- app.config.json: ship the default signinOrder.
- LoginConfig + LoginMapper: carry signinOrder through to the login feature.
- LoginCubit: take signinOrder and feed it to sortLoginTypes; drop the
  WEBTRIT_APP_LOGIN_TYPES_ORDER dart-define.

The order still resolves through the same pure sortLoginTypes(): an empty or
unknown config falls back to the password-first default, and the backend
supported[] list keeps deciding only which options are shown.

WT-1464

* fix(login): honor signin order in login preview screenshots

The configurator preview rendered the login tabs in a fixed order
(OTP, Password, Sign up) regardless of AppConfigLogin.signinOrder, so
reordering the sign-in tabs in the configurator had no visible effect.

Read signinOrder from FeatureAccess and reorder the preview tabs via the
same sortLoginTypes() helper the app uses. The interactive login preview
now also defaults its selected tab to the first configured one (per-tab
snapshots keep their pinned initialLoginType, which is now nullable).

WT-1464
Introduce docs/features/ for product-level, living feature overviews, separate
from the architecture deep-dives. First entry documents the call feature: where
it lives, what the user can do, the single/multi-call UX states, the focused-call
seam, key widgets, and the in-progress list-based call-flow redesign with its
incremental rollout. Includes a README that sets the convention (one living doc
per feature, current behavior first, in-progress work clearly marked).
* feat: expand recents and favorites rows into quick actions instead of instant dial (WT-529)

* refactor: unify favorite tile onto the shared call tile (WT-529)

* feat: add quick actions expansion to contacts list items (WT-529)

* refactor: address review feedback on quick actions expansion
* feat(call): add focused-call state to CallBloc (#1376)

Foundation for the list-based call screen redesign. Introduces an explicit
"focused" call so the upcoming UI can do "tap a row to focus, act on that row".

- CallState.selectedCallId + focusedCall getter (selected when it maps to a
  live call, otherwise the derived current; null only with no active calls).
- CallControlEvent.callSelected + bloc handler, clamped to a live call via
  copyWithSelectedCall.
- copyWithPopActiveCall drops a dangling selection so focusedCall falls back
  to current.

Purely additive and behavior-preserving: nothing dispatches callSelected yet,
so selectedCallId stays null and focusedCall mirrors current. Covered by
call_state_test (focusedCall fallback/selection, copyWithSelectedCall clamp,
pop-clears-selection).

* refactor(call): move combined call actions into CallBloc intents (#1378)

* refactor(call): move combined call actions into CallBloc intents

Second foundation step for the list-based call screen. The call screen used to
synthesize multi-step actions itself by dispatching several primitive events in
a loop; the upcoming single action area needs one intent per action.

- New CallControlEvent intents: answeredEndingOthers ("End & Answer"),
  answeredHoldingOthers ("Hold & Answer"), swapped.
- Pure planners on CallState (planAnswerEndingOthers / planAnswerHoldingOthers /
  planSwap) return the ordered primitive events; bloc handlers just dispatch
  them, so the multi-step semantics are unit-testable without a full CallBloc.
- CallActiveScaffold now dispatches one intent per action instead of looping;
  the primitives flow through the same sequential CallControlEvent queue in the
  same order, so behavior is unchanged.
- docs/features/call.md: rollout reflects the refactor/call integration branch
  (the earlier feature-flag wording was outdated) and stage statuses.

Covered by call_state_test planner groups (ordering, only-call edge cases).

* refactor(call): keep combined-action plans out of CallState

Review feedback: the state layer must not construct or reference events. The
plans now live as static helpers on CallControlEvent (events composing events,
same layer); CallState only exposes the pure data query otherCallIds. Bloc
handlers feed the ids into the plans and dispatch. Tests split accordingly:
otherCallIds on the state side, the three plans on the event side.

* feat(call): render concurrent calls as a selectable list (#1379)

First visible step of the list-based call screen. With more than one call in
progress the screen now shows one tappable row per call (status badge RINGING /
ON CALL / ON HOLD, name, live duration) under an "N calls - tap to choose"
header, replacing the stacked per-call info blocks and per-call button rows.
Tapping a row focuses that call; the info block and the action area below act
on the focused call only.

- New CallList/CallRow widgets (presentational; ticking duration for answered
  calls, focused-row highlight).
- Auto-focus rules in CallState: a new ringing incoming call grabs the focus;
  when the focused call ends the next ringing incoming call is focused,
  otherwise focus falls back to the derived current call.
- CallActiveScaffold renders CallInfo + actions for the focused call;
  IncomingCallActions for a ringing focus, ActiveCallActions otherwise. The
  media overlay keeps following the derived current call, and swap keeps its
  hold-the-active semantics.
- l10n: call_CallList_header (plural) + call_CallList_statusOnCall in en/it/uk;
  badges reuse the existing ringing/on-hold keys.
- docs/features/call.md rollout statuses updated.

Tests: CallList widget suite (rows, badges, header visibility, tap dispatch,
focused highlight, ticking duration) and CallState auto-focus/pop-refocus
cases.

* feat(call): focused-call action area with acting-on hint (#1380)

Core step of the list-based call screen: the focused ringing call now gets
exactly two buttons - Decline and Answer - with an "Acting on: <name>" hint
that spells out what answering does to the other calls. The combined-icon
buttons (call_end+call "End & Answer", pause+call "Hold & Answer"), which were
hard to read mid-ring, are removed.

- IncomingCallActions trimmed to Decline/Answer; the combined-icon buttons and
  their callbacks are gone.
- CallControlEvent.answerFocused: pure selection of the single Answer intent -
  hold the answered others when possible, end the non-holdable ones, plain
  answer otherwise. Another ringing incoming call keeps ringing.
- FocusedActionHint (new widget): names the focused call and the side effect
  ("will be put on hold" / "will be ended"); shown only with multiple calls.
- Answering with other calls present is gated by the interactions debounce
  like any signaling-dependent action.
- l10n: call_FocusedActionHint_{actingOn,willBeHeld,willBeEnded} in en/it/uk.
- docs/features/call.md: multi-call section now describes the list-based
  behavior; widget table and rollout statuses updated.

Tests: FocusedActionHint suite (acting-on line, held/ended side effects,
precedence, multi-name join, no-effect case), IncomingCallActions (exactly two
buttons, no pause glyph, callback wiring), answerFocused selection cases.

* refactor(call): remove redesign leftovers and cover the call screen with tests (#1381)

Final cleanup of the list-based call screen.

- IncomingCallActions: drop the dead enableInteractions parameter (nothing read
  it after the combined buttons were removed) and the unused keypad text
  controller; the scaffold call site no longer computes the unused gate.
- l10n: delete the obsolete call_CallActionsTooltip_{hangupAndAccept,
  holdAndAccept} keys from en/it/uk and regenerate gen-l10n + the l10n mapper.
- New scaffold-level widget tests (CallActiveScaffold with a mocked CallBloc):
  single-call states (incoming / active / held) render the right action set
  with no list and no hint; active+incoming shows the list, the acting-on hint
  with the hold side effect and exactly the two-button area; Answer dispatches
  answeredHoldingOthers for the focused call; tapping the active row dispatches
  callSelected; focusing the active call swaps in the control grid; the 3-call
  scenario renders three rows and names only the still-active call in the hint.
- docs/features/call.md rollout statuses updated.

* feat(call): move connection and stream-quality status to the toolbar (#1385)

* feat(call): move connection and stream-quality status to the toolbar

The call screen toolbar (AppBar title slot) now carries one global status
line, freeing the central info block for the caller identity and timer, as in
the design.

- CallToolbarStatus (new widget), one indicator at a time by priority:
  signaling/connectivity trouble (red dot + status + "Reconnecting..." with
  the same debounce the in-info message used), a real media failure
  (IceConnectionIssue), or media degradation - the signal meter plus an
  always-visible label.
- The quality/failure shown is GLOBAL: new ActiveCall iterable getters
  worstNetworkQuality (active warning beats recovered, then higher severity)
  and firstIceConnectionIssue aggregate across all calls.
- CallNetworkQualityMeter gains an optional showLabel override so the toolbar
  can render its own label without duplicating the severe one.
- CallInfo slimmed down: callStatus/networkQuality/iceConnectionIssue
  parameters, the status debouncer and the meter slot are gone; it renders
  name/number, call description or duration, and processing status only.
- l10n: call_ToolbarStatus_reconnecting in en/it/uk.
- docs/features/call.md updated (status line description + rollout table).

Tests: CallToolbarStatus suite (healthy renders nothing, connectivity text +
reconnecting suffix, unregistered without the suffix, quality meter + label at
any severity, status-over-quality and failure-over-quality precedence) and
aggregation cases for worstNetworkQuality / firstIceConnectionIssue.

* feat(call): refine toolbar status states per design

Design iteration on the toolbar status line:

- Signaling states split per the design: "No internet connection" /
  "Not registered" are hard states with a coral static dot; the transient
  reconnect cycle shows an amber PULSING dot with "Connecting..." on the very
  first connection and "Reconnecting..." once a connection has existed
  (tracked in the widget). The suffix-style "Reconnecting..." is gone.
- The status line is centered in the toolbar.
- The quality indicator is bars + direction arrow only: the meter gains a
  showMediaGlyph override and the toolbar suppresses the mic/camera glyph
  (the label already says audio/video).
- l10n: call_ToolbarStatus_connecting added in en/it/uk.

Tests updated: hard states show only their own message; first connection ->
Connecting; ready-then-drop -> Reconnecting after the debounce; quality row
keeps the arrow and drops the media glyph.

* fix(call): align the multi-call screen visuals with the design (#1386)

Visual-alignment pass over the list-based call screen:

- CallRow gains a colored status dot (amber ringing, green on call, grey
  held) and the ringing trailing label becomes the short Incoming/Outgoing
  form (new call_CallList_incoming/outgoing keys in en/it/uk).
- The central info block is rendered for a single call only - with multiple
  calls the rows carry the per-call info, as in the design.
- The acting-on hint is now a translucent pill pinned right above the action
  buttons (hint + buttons share one column so the space-between layout keeps
  them glued), with the focused name in bold and the affected names
  highlighted in amber.
- Action buttons stay untouched.

Tests updated: hint assertions match the rich-text spans, the ringing row
asserts the short Incoming label, and the scaffold suite pins the central
info block presence for a single call and its absence with multiple calls.

* fix(call): make hold and resume act on the focused call (#1387)

With two calls the hold slot in the control grid used to be REPLACED by the
swap button, so a held call could never simply be resumed and the button
ignored which row was focused - pressing it always switched lines via the
derived current call. The focus model makes swap redundant: switching lines is
focusing the other row.

- The hold slot is now always Hold/Resume for the FOCUSED call, with a
  pause/play glyph reflecting the state.
- Resume dispatches the new resumedHoldingOthers intent: the other answered,
  not-yet-held calls are put on hold first (CallState.otherCallIdsToHold),
  then the focused call is resumed - exactly one call stays live. With two
  calls this covers the old swap.
- Hold stays a plain setHeld on the focused call.
- The swap button, its callback chain, the swapped event/plan, the widget key
  and the swap tooltip l10n keys are removed.

Tests: resumeHoldingOthersPlan ordering (+ resume-only when nothing else is
live), otherCallIdsToHold filtering (skips held, ringing and the target), and
scaffold cases - Hold on an active focus dispatches setHeld(true); a held
focus shows the play glyph, no swap button, and Resume dispatches the new
intent.

* fix(call): match the call row overlay polarity to the design (#1388)

The rows tinted themselves with colorScheme.surface, which resolves to a dark
color on the standard theme - so the focused row came out DARKER than the
rest, inverting the design. Rows now use light overlay tints of the on-screen
text color (white on the call gradient): the focused row is the brighter one
with a light border, unfocused rows stay dimmer. Corner radius and padding
bumped to the design proportions (16/14 with a 6px gap).

docs: rollout table - Hold/Resume row flipped to merged, new row for this
polish.

* feat(call): mark video lines in the call list (#1389)

A small camera glyph next to the trailing duration/direction label marks the
rows whose call carries video (ActiveCall.remoteVideo - confirmed remote video
track or the negotiated video flag), matching the design.

Tests: a video line shows the glyph, voice lines do not. docs rollout updated
(row overlay polish flipped to merged, this badge added).

* fix(call): route the redesign colors through the theme pipeline (#1390)

Removes the fixed colors the redesign introduced and gives them proper theme
roles, so branded builds restyle them from the theme JSONs:

- webtrit_appearance_theme: CallPageConfig gains callList
  (CallPageListConfig: row/focused-row/border overlays + ringing/on-call/held
  dot colors) and actingOnHint (CallPageHintConfig: pill background +
  affected-name highlight); codegen regenerated.
- assets/themes: original.page.{light,dark}.config.json set the design values
  under "dialing" (white overlay tints, amber/green dots, scrim pill, amber
  highlight) - alpha-first #AARRGGBB hex.
- App: CallScreenStyle gains CallListStyle and FocusedActionHintStyle (with
  lerp), mapped in CallScreenStyleFactory with scheme-derived defaults when a
  theme omits the section.
- Widgets: CallRow and FocusedActionHint consume the style objects; the only
  remaining in-widget fallbacks derive from the ambient text color/scheme -
  no Colors.* or fixed hex anywhere in the call feature.

Tests: full call suite green (452); grep for fixed colors over the branch
diff is clean.

* fix(call): drop the programmatic uppercase on call row badges (#1392)

The row status badge uppercased its localized text at render time
(.toUpperCase() on call_description_held etc.) - a typographic hack that also
broke the patrol transfer assertion, which looks for the plain "On hold"
string. The badge now renders the localization as is; the patrol audit change
is reverted since the original assertion matches the badge again.

The visual caps treatment, if still wanted, belongs to the text style (theme),
not to string mangling. Widget tests updated accordingly.

* refactor(call): address PR review feedback on the integration branch (#1393)

Review findings on the refactor/call -> develop PR:

- AGENTS.md requires single-expression callbacks: the multi-statement
  closures the redesign added to CallActiveScaffold (camera toggle,
  hold/resume, hangup x2, answer) move into private focused-call intent
  helpers; the widget tree passes tear-offs. The answer helper re-derives the
  other-call sets from widget.activeCalls, so behavior is identical.
- docs/features/call.md widget table: CallInfo no longer shows the network
  quality meter (it lives on the toolbar status line) - the row now lists
  name / number / call description / timer.

The remaining review comment (ActiveCall import) is a false positive:
call_state.dart is part of call_bloc.dart, so the import provides ActiveCall.

No behavior changes; full call suite green (452).
* feat: add Play Core in-app update flow for Android (WT-945)

* docs: describe the app update and version compatibility flows

* test: cover the failed flexible update branch

* refactor: delegate lifecycle handling to a private method

* refactor: check for app update on startup only
#1397)

* style: remove inaccurate comment from voicemail audio cache file guard

* style: add accurate comment for iOS cache file guard

* style: add comment for local path condition in cache file guard
…ack (#1398)

* fix: localize missed call notification title and unknown caller fallback

Resolve hardcoded 'Missed Call' and 'Unknown' strings that appeared in local
push notifications regardless of the device language. The isolate context now
reads the persisted locale via LocaleRepositoryPrefsImpl and passes resolved
l10n strings through PushNotificationIsolateManager and CallBloc constructors.
New ARB keys notifications_missedCall_title/unknownCaller added for EN/IT/UK.

* refactor: inject onMissedCall callback into CallBloc instead of LocalPushRepository + title fields

CallBloc no longer holds LocalPushRepository or missedCallTitle directly.
The caller (main_shell) owns the notification logic via an injected callback,
keeping the bloc unaware of push internals.

* refactor: inline onMissedCall at call site, remove _showMissedCallNotification wrapper

* refactor: make _onMissedCall field private in CallBloc

* fix: resolve l10n title lazily inside onMissedCall lambda, not during BlocProvider.create

* refactor: inject onMissedCall callback into PushNotificationIsolateManager

Replace localPushRepository, missedCallTitle, and unknownCallerFallback
constructor params with a single Future<void> Function(String, String?)
callback so the manager does not depend on push_notifications internals.

* fix: guard lookupAppLocalizations against unsupported locale in push isolate

PushIsolateContext.locale returns the 'und' sentinel when no locale has
been selected, causing lookupAppLocalizations to throw FlutterError.
Resolve the stored locale to a supported one (falling back to EN) before
the lookup.

Also document the BlocProvider.create l10n restriction in AGENTS.md.
…(WT-1236) (#1400)

* feat: surface camera-permission downgrade when answering a video call

Answering an incoming video call without camera permission falls back to
audio-only. Communicate that downgrade with two layers: a soft snackbar with
an Open Settings action at answer time, and a persistent camera-button hint
that re-checks the live permission and routes to app settings while it stays
denied.

The downgrade is derived in the bloc (the offer requested video but the
resulting stream is audio-only) and confirmed against the live camera
permission; the media builder is left unchanged.

* fix: gate camera permission-denied tap and clear stale downgrade hint

Address review findings on the camera-permission downgrade UX:

- The permission-denied camera-button tap bypassed the enableInteractions
  gate that the normal toggle respects, so a mid-call grant could dispatch a
  camera enable (and SDP renegotiation) during a renegotiation/not-ready
  window. Route it through the same gated local as the normal toggle.
- ActiveCall.videoPermissionDenied was cleared only on the camera-enable
  success path. Clear it on the existing-video-track path too, and re-derive
  it from the live permission when an enable attempt fails, so the button no
  longer reports "permission denied" after the user has granted access.

* fix: harden camera permission-denied flow per review

Address Copilot review feedback on the camera-permission downgrade UX:

- Move the optional isCameraPermissionGranted param after the required ones
  (required-before-optional convention).
- Make the live permission check failure-tolerant: a throwing permission
  plugin no longer breaks call answering or camera toggling; the unknown
  case is treated as granted (no block, no misleading hint).
- Skip the post-await videoPermissionDenied emit when the call has ended.
- Disable the camera button (onPressed: null) when its handler is gated,
  consistent with the mute button, instead of leaving it a no-op.
- Add widget tests covering the permission-denied tooltip and the tap paths
  (enable when granted, open settings when still denied).
…nse file (#1401)

Add a Third-party licenses entry to the About screen via Flutter's
showLicensePage (auto-collects every bundled pub package, incl. BSD-2
icon_decoration) with a new l10n key in en/it/uk.

Add tool/generate_licenses.dart (melos run licenses:generate) that
aggregates the license text of all third-party packages from the resolved
dependency set into THIRD_PARTY_LICENSES.md, plus a CI guard that fails a
PR when the file drifts out of sync.

WT-771
…1363) (#1399)

* fix: enforce exclusive voicemail playback via shared AudioPlayer (WT-1363)

Introduce VoicemailPlaybackController (ChangeNotifier) that owns a single
AudioPlayer for the voicemail screen. AudioView becomes a StatelessWidget
that reads the controller from context: the active tile renders
AudioPlayerInterface against the shared player; inactive tiles render a
static play button. Switching tracks stops the previous one automatically
since only one AudioPlayer exists.

The controller also handles audio session setup, LockCachingAudioSource
construction, playback-completed reset, and app-lifecycle pause -- concerns
previously scattered across every AudioView instance.

* fix: debounce loading state to prevent blink on cached voicemail switch

Show AudioPlayerInterface immediately on track switch (optimistic UI).
The loading spinner is deferred by 200 ms via a Timer -- it only fires
for slow/uncached sources and is cancelled when setAudioSource resolves
before the threshold. Eliminates the play-button blink when toggling
between already-cached messages.

* refactor: replace raw Timer with project Debounce utility in playback controller

* fix: address Copilot review findings in voicemail playback controller

- Retry after error: fall through play() early-return when _error != null,
  so tapping play on an errored tile re-runs setAudioSource instead of
  calling player.play() on an unloaded source.
- Race condition: add a _generation token; each async await checks whether
  the token is still current, and stale continuations return early without
  mutating state or calling the player.
- Error recovery: stop the player in the catch block so a previously playing
  voicemail does not continue after a new-track load failure.
- Path traversal: reject a cacheKey of '.' or '..' in resolveCacheFile, which
  separator-stripping alone let escape cacheBasePath via path.join.
- Inactive slider: wrap AudioSlider in IgnorePointer in _InactiveAudioView
  to prevent users from dragging a no-op seek control.
- Testability: accept optional AudioPlayer and setupAudioSession in the
  constructor for unit-test injection, and expose resolveCacheFile via
  @VisibleForTesting (production path unchanged).
- Tests: unit tests covering state transitions, debounce timing, race guard,
  error handling, lifecycle, completion, dispose, and cache-key path traversal.
* fix(recents): update RecentTile call logic and icons

- Dynamically change dial icon based on last call type (audio/video).
- Ensure quick access dial button initiates the correct call type.
- Show only the alternative call type in the expanded actions bar to avoid redundancy.

* test(call_tile): update test to reflect new audio call action

* fix(recents): gate audio quick action by videoEnabled and drop leftover comment

* test(call_tile): cover custom dialIcon on the dial button

* test(call_tile): assert audio action placement and callback instead of label count
…ed reason(WT-1419) (#1402)

* feat: show neutral notification when an outgoing call fails for an unrecognized reason

The hangup handler already maps known SIP codes (rejected, busy, not-found,
invalid number, unwanted) to neutral message notifications. The default branch
only logged and reported to Crashlytics, so any other failure was silent.

Add a generic CallUnableToCompleteNotification (extends MessageNotification, so
it renders neutral, not red) backed by a new signalingResponseCode_unableToComplete
l10n key (en/it/uk) and submit it from the default branch, keeping the existing
Crashlytics record. Intentionally-silent codes stay silent: byCode returns null
for unknown/normal-termination codes, which hit the silent null case.

* fix: drop stray brace in hangup Crashlytics error title

* feat: align known call-failure notifications with the ticket wording

Shorten the four known outgoing-call failure messages to the concise neutral
labels from the spec: "Call rejected", "User busy", "User does not exist",
"Invalid number format" (en/it/uk). The previous rejected string in particular
was a long, technical phrase that did not match the actual reason.
…78) (#1403)

Replace the placeholder TODO in CallBloc.onChange with a short pointer comment
and move the full explanation into docs/signaling_architecture_target.md under
a new "Background call-active edge (onChange)" subsection.

The block re-notifies SignalingReconnectController when CallState.isActive flips
while the app is backgrounded - a gap _onAppLifecycleStateChanged cannot cover
because it samples isActive only at the foreground/background transition. The
doc section explains each notify* call; the code comment links to it.
…7) (#1404)

Replace the placeholder TODO in CallBloc.onError with a short pointer comment
and move the rationale into docs/signaling_architecture_target.md under a new
"Call finalization on signaling loss (onError)" subsection.

onError is the BLoC catch-all and stays a pure logger by design: it has no call
context and a live call must survive a transient signaling drop. Finalizing a call
whose signaling is lost is handled by four narrower paths with call context -
survive-and-recover, remote hangup, requestCallIdError cleanup, and the visible
ICE-issue flag. The doc section tables these; the code comment links to it.

No behaviour change - documentation only.
…ng guide in AGENTS.md (WT-1077) (#1405)

* docs: relocate call architecture/scenario docs under features (WT-1077)

The call-feature deep-dives lived at the docs root while the feature doc lives in
docs/features/. Move them next to the feature so root keeps only cross-cutting,
component-level docs and docs/features/ holds everything about a given feature:

- docs/call_architecture.md       -> docs/features/call_architecture.md
- docs/incoming_call_scenarios.md -> docs/features/incoming_call_scenarios.md

Also fix the placement from #1403/#1404: the CallBloc onChange/onError subsections
were added to signaling_architecture_target.md (the signaling-component doc) but are
CallBloc behaviour. Move them into features/call_architecture.md under a new
"Signaling edges (onChange / onError)" section; signaling_architecture_target.md now
points there. Update all cross-links (features/call.md, features/README.md taxonomy,
CLAUDE.md) and the two code comments in call_bloc.dart. signaling_architecture_target.md
stays at root as a cross-cutting component doc.

No behaviour change - documentation only.

* docs: split call product/UX out of call.md into call_ux.md (WT-1077)

call.md mixed the product/UX view (what the user does, screen states, key widgets,
the in-progress redesign) with feature orientation. Extract the product/UX content
into docs/features/call_ux.md and reduce call.md to a short feature index that points
to the UX, architecture, scenario and signaling docs.

No behaviour change - documentation only.

* docs: merge call_architecture.md into call.md (WT-1077)

Keep one doc per concern for the call feature: call.md now holds both the feature
overview and the CallBloc architecture deep-dive (responsibilities, events, state
machine, flows, isolates, key patterns, signaling edges); call_ux.md keeps the
product/UX view. Remove the separate call_architecture.md and repoint all references
(CLAUDE.md, signaling_architecture_target.md, features/README.md, call_ux.md, and the
two call_bloc.dart comments) to call.md.

No behaviour change - documentation only.

* docs: adopt _ux / _arch feature-doc split for call (WT-1077)

Establish a per-feature doc pattern and apply it to call:
- <name>.md     - index/overview (summary, where it lives, links)
- <name>_ux.md  - product / UX
- <name>_arch.md - code / architecture

Move the CallBloc architecture deep-dive out of call.md into call_arch.md, keep
call.md as the index, and document the pattern in features/README.md (File pattern
+ Conventions). Repoint references (CLAUDE.md, signaling_architecture_target.md,
call_ux.md, and the two call_bloc.dart comments) to call_arch.md.

No behaviour change - documentation only.

* docs: move incoming_call_scenarios.md to docs root as cross-cutting (WT-1077)

The incoming-call scenarios span push, callkeep and signaling, not just CallBloc, so
they belong at the docs root next to the other cross-cutting docs rather than under
docs/features/. Move the file back to docs/ and repoint links (features/call.md,
features/call_arch.md, features/README.md scenario notes).

No behaviour change - documentation only.

* docs: drop per-feature index file, rename features README to features.md (WT-1077)

The per-feature index (call.md) duplicated the features index. Remove it and let the
central index serve that role; move call.md's "Where it lives" into call_arch.md.
Rename docs/features/README.md -> docs/features/features.md as the explicit index, and
update the file pattern: a small feature stays a single <name>.md, a grown one splits
into <name>_ux.md + <name>_arch.md with no redundant index. Point the index row for
call at call_ux.md + call_arch.md.

No behaviour change - documentation only.

* docs: move feature-doc authoring guide from features.md to AGENTS.md (WT-1077)

The how-to-write-docs guidance (taxonomy, file pattern, conventions) is agent
instruction, not feature content. Move it into AGENTS.md under a new "Documentation"
section and reduce features.md to just the index, pointing at AGENTS.md for the rules.

No behaviour change - documentation only.

* docs: make features.md index a labeled per-feature nav table (WT-1077)

Give each feature labeled doc links (UX / Architecture, or Overview for a single-file
feature) instead of bare filenames, so the index is a convenient navigation table for
all features in the folder.

No behaviour change - documentation only.

* docs: reformat feature docs (table alignment) (WT-1077)

Whitespace-only: align markdown table columns in the feature docs. No content or
link changes.
#1407)

* fix(call): end native call on signaling hangup for a call not in state

* fix(call): report missedWhileConnecting end reason for a hangup not in state

Lets callkeep distinguish this never-presented end (which should suppress a stale
ghost re-presentation) from a transfer-back, without a timing window.
…1409)

* feat: add min_supported_app_version to system-info models (WT-1628)

Prepare the data layer for the upcoming force-update gate without any
behavior or UI changes. The backend exposes an optional
min_supported_app_version (semver string) in GET /api/v1/system-info
(null = not enforced); this threads the field through the model layers so
a later change can consume it.

- api.SystemInfo: new optional top-level `min_supported_app_version`,
  carried as a raw String? (the API layer stays a dumb transport).
- WebtritSystemInfo: new parsed `Version? minSupportedAppVersion` + props.
- API->domain mapper: safe parse (String? -> Version?, malformed -> null).
- JSON cache mapper: persist the field so cached system-info round-trips.

No reads of the field yet; no gates, helper, dialog or l10n.

* refactor: tolerant version parsing for system-info min version + tests (WT-1628)

Address review on the data-layer prep: the JSON cache mapper used a raw
Version.parse for min_supported_app_version, which throws if the persisted
value is malformed, empty or not a String. Mirror the tolerant parsing used
on the API side and share it.

- New util tryParseVersion(Object?) -> Version? (null on null/non-String/
  empty/malformed); reused by both the API and JSON cache mappers.
- Drop the private _tryParseVersion duplicate from the API mapper.
- Tests: tryParseVersion unit; API->domain + JSON cache round-trip
  (valid / null / malformed-stays-null); api SystemInfo wire contract for
  min_supported_app_version (present + absent).
…1410)

* refactor: unify contact number actions into a single source of truth

The inline actions bar and the popup menu each maintained their own
hardcoded list of contact actions, so the same action (audio/video call,
history, view contact) appeared in both surfaces at once, with diverging
labels (History vs View call history, Contact vs View Contact). The More
button reopened the full menu, repeating everything already in the bar.

Introduce NumberAction plus a single buildNumberActions builder that both
surfaces derive from: the inline bar renders the primary actions, the More
menu shows only the rest, and long press still exposes the full set on a
collapsed row. ContactPhoneTile now builds its overflow menu from the same
builder instead of its own hardcoded entries.

* fix: do not duplicate the dial call action in the expanded bar

The trailing dial button is a static call shortcut that stays visible in
both states. When the tile is expanded, drop the inline call action of the
same kind as the dial button (audio or video) so the actions bar no longer
repeats it.
…rver capability (WT-727) (#1412)

* feat(cdrs): gate call history by local flag AND server capability (WT-727)

Reintroduce a local opt-in for remote call history on the recents tab.
Partially reverts the server-only gating: CDRs are shown only when both
signals agree - the local RecentsTabScheme.supportsCallHistory flag AND
the server callHistory adapter capability.

The DTO reads the supportsCallHistory key and falls back to the legacy
useCdrs key on parse, so existing client configs keep their value. The
runtime RecentsBottomMenuTab.supportsCallHistory now holds the resolved
(local && capability) value, so downstream consumers are unchanged.

Also align stale enabled expectations in the appearance_theme parsing
test with the committed app.config.json fixture.

* refactor(cdrs): address review on the call-history local-flag gating

- DTO migration shim uses containsKey instead of `??`, so an explicitly
  present supportsCallHistory key (even null) wins over the legacy useCdrs
  key; add a parsing test for the explicit-null case.
- rename the mapper-test helper recentsUseCdrs -> resolvedSupportsCallHistory
  to drop the deprecated useCdrs terminology and match what it returns.

* feat(cdrs): add Firebase Remote Config override for call history

Introduce a third source for the recents call-history gate. The local
config flag (supportsCallHistory) can now be overridden by the Firebase
Remote Config boolean `feature_call_history_enabled` via FeatureOverrides,
following the same precedence pattern as the other remote overrides.

The remote value, when set, replaces the local config flag, but it never
bypasses the server callHistory capability. Resolution:
(feature_call_history_enabled ?? supportsCallHistory) && callHistory.

- FeatureOverrides: add isCallHistoryEnabled + the feature_call_history_enabled key.
- BottomMenuMapper.map/_createBottomMenuTab: take FeatureOverrides and apply the
  override to the recents local flag before the capability gate.
- tests: extend the recents matrix with the Firebase override dimension.
- docs: document the remote override and resolution order.

* refactor(cdrs): address review on the Firebase call-history override

- FeatureOverrides: include isLogAnonymizationEnabled in props so Equatable
  equality reflects that flag.
- BottomMenuMapper recents handler: inline to a single-expression callback per
  project convention (multi-statement logic not allowed in callbacks).
- tests: clarify two recents test names (callHistory is advertised).
…1413)

* fix(login): allow letters in OTP sign-in identifier field (WT-1642)

Account references may be alphanumeric (e.g. ph123x456) even when the backend
advertises phone as the only OTP identifier. Aligning the keyboard with the
identifier in WT-1433 forced a phone dial pad for the phone-only case, which
blocks letters and regressed 1.15.2 behaviour.

Use a text keyboard whenever email is not advertised, keeping the email keyboard
only when email is an accepted identifier. Covered by extension keyboard-type
tests.

* style(login): separate external import group in OTP identifier test
…1408)

* feat: enforce min_supported_app_version force-update gate (WT-1628)

The backend now declares an optional min_supported_app_version in
GET /api/v1/system-info (null = not enforced). The app reads it,
semver-compares against its own version, and when the app is older it
shows a non-dismissible update prompt and does not establish a
signaling-connected session. This is the inverse of the existing core
compatibility check.

- Thread the field through the 4 data layers (api SystemInfo as String?,
  WebtritSystemInfo as Version?, api + json cache mappers).
- Add WebtritSystemInfo.isAppVersionSupported helper (null / 0.0.0 debug
  guard / below / equal / above).
- Gate the 3 system-info sites: login (pre-session block via return null),
  main bloc (running/persisted session -> AppVersionUnsupported + dialog),
  autoprovision (parity).
- New AppUpdateRequiredDialog + l10n in en / it / uk.
- Unit tests for the helper and the login + main-bloc gates.

* feat: full-screen update-required UI for the force-update gate (WT-1628)

Replace the placeholder AlertDialog with a full-screen, non-dismissible
update-required screen matching the approved design.

- Dialog.fullscreen + PopScope(canPop: false): no back button, no barrier
  dismiss; the user must update or log out.
- Layout (mirrors the app's blocking-screen pattern): concentric blue badge
  with an upward arrow + accent sparkle, bold title, muted description, a
  bordered card contrasting current vs minimum required version (tabular
  figures), then a primary Update button and a Logout text button.
- Uses theme tokens only (colorScheme + ElevatedButtonStyles.primary), so it
  follows light/dark and white-label themes.
- l10n: split the old combined content string into a placeholder-free
  description plus current/minimum version labels (en / it / uk).
- Widget tests: renders title/description/versions/actions, hides Update when
  no store URL, fires the update/logout callbacks.

* fix(main): close force-update gate on root navigator so logout works (WT-1628)

The gate dialog is pushed on the root navigator (showDialog defaults to
useRootNavigator: true), but the logout/update callbacks resolved
Navigator.of(context) against the nested auto_route navigator and used
maybePop(), which PopScope(canPop: false) blocks. The dialog therefore
never closed on logout; MainScreen was torn down underneath it and the
next tap dereferenced a defunct context, throwing
"Null check operator used on a null value" in Navigator.of.

Capture AppBloc/MainBloc and the root navigator while the context is
mounted, and force-pop the gate via the root navigator before dispatching
logout.

* refactor(main): consolidate min-supported-app-version gate responsibilities (WT-1628)

The force-update gate logic was spread across bloc, screen and dialogs, each
holding knowledge about the others. Tighten the boundaries:

- bloc: extract a pure _resolveCoreVersionVerdict() (app-too-old > core
  incompatible > compatible) and attach the async store URL separately in
  _withStoreUpdateUrl(). Drops the minSupportedAppVersion! null-assertion - the
  AppVersionUnsupported branch now guards non-null itself.
- dialogs: AppUpdateRequiredDialog/CompatibilityIssueDialog own their dismissal,
  popping via their own context (plain pop(), which PopScope does not block).
- screen: MainScreen no longer knows about the root navigator or force-pop; it
  only maps state to a dialog and wires pure update/logout intents.

Supersedes the force-pop approach from the previous commit with a cleaner
self-dismiss; logout still works and no longer dereferences a defunct context.

* refactor(version-gate): extract shared AppCompatibility domain decision (WT-1628)

The login gate (LoginCubit) and the in-app force-update gate (MainBloc) each
re-derived the same two version checks (core constraint + min_supported_app_version)
with their own priority order. Centralize the rule in a pure domain type,
AppCompatibility.resolve(systemInfo, appVersion, coreVersionConstraint), and make
both call sites thin mappers to their own surface (login notification / bloc state).

- Single source of truth for the checks, their priority, the non-null-minimum
  guard, and the 0.0.0 debug/sideload exemption.
- Unifies priority across both gates: app-too-old now takes precedence over a
  core mismatch in LoginCubit too (previously core-first). Only affects the rare
  case where both checks fail at once; app-too-old is the more actionable message.
- Adds app_compatibility_test.dart covering each outcome and the priority.

* refactor(version-gate): inject AppCompatibilityResolver instead of a static call (WT-1628)

Make the version-compatibility policy an injected collaborator rather than a
direct static call, so it is a single swappable/mockable seam (and a future
remote-config/kill-switch policy can replace it without touching consumers).

- Add AppCompatibilityResolver interface + const DefaultAppCompatibilityResolver
  holding the existing rule; drop the static AppCompatibility.resolve factory.
- Provide it once via RootApp's MultiProvider (const, no bootstrap registration).
- Inject into MainBloc and LoginCubit via constructor; both read it from context.
- Update gate tests to pass const DefaultAppCompatibilityResolver().

* refactor(version-gate): move force-update gate to an AppRouter guard + pages (WT-1628)

Replace the MainBloc + full-screen dialog overlay with a router-level gate. The
dialog approach let the real main content render for a frame before the overlay
covered it (visible blink); a guard redirect to a dedicated page never builds the
protected content.

- AppBloc owns the gate signal: subscribes to systemInfoRepository.infoStream,
  resolves AppCompatibility via the injected resolver, and holds it in AppState.
  appCompatibility is added to compareToReevaluate so router guards reevaluate the
  moment the gate flips (covers mid-session min-version changes).
- New version_gate feature: UpdateRequiredScreenPage (app too old) and
  CompatibilityIssueScreenPage (unsupported core), ported from the former dialogs
  as @RoutePage screens (PopScope blocks back; store URL resolved on the page).
- AppRouter.onMainShellRouteGuardNavigation redirects to the right page based on
  AppState.appCompatibility (after system-info, before agreements/permissions);
  recovery guards return to MainShell once authenticated-and-supported again, and
  bounce logged-out users onward so logout works from the gate.
- Remove MainBloc/MainBlocState/MainBlocEvent (it was entirely this gate), both
  dialog widgets, and their tests. MainScreen is now a plain layout widget.
- Add AppBloc gate tests and an UpdateRequiredScreenPage widget test.

* fix(autoprovision): use shared AppCompatibilityResolver for the version gate (WT-1628)

AutoprovisionCubit re-derived the core/app-version checks in the old order
(core first), so when both failed it surfaced "core unsupported" instead of the
intended app-too-old priority used by the login and in-app gates. Inject the
shared AppCompatibilityResolver and switch on its result, matching the unified
priority and removing the duplicated checks.

Addresses the Copilot review comment on autoprovision_cubit.dart.
Aligns develop app_version to 1.15.4+2 and callkeep lock to 1.2.0+0 after the 1.15.4 release. develop keeps the callkeep path pin.
… (WT-1631) (#1419) (#1427)

* feat(theme): configurable dialog theme and readable dialog background

Material 3 derives the dialog background from colorScheme.surfaceContainerHigh,
which resolves to the seed color in the original scheme and made the confirm
dialog (e.g. logout) render as dark text on a near-unreadable surface. The app
also had no ThemeData.dialogTheme at all, so there was nothing to override.

- Add a global DialogThemeData (DialogThemeDataFactory) wired into
  ThemeData.dialogTheme; background falls back to colorScheme.surface and the
  surface tint to transparent, so all dialogs stay legible without any config.
- Add DialogThemeConfig (background/surfaceTint/shadow/barrier colors,
  elevation, borderRadius, title/content text styles) for global dialog styling.
- Extend ConfirmDialogWidgetConfig with background/surfaceTint/elevation/
  borderRadius and title/content text styles as confirm-dialog-specific
  overrides layered on top of the global dialog theme.
- Apply the new properties in ConfirmDialog's AlertDialog; extend
  ConfirmDialogStyle with merge/lerp for the added fields.

All new config fields are nullable, so existing themes keep parsing and unset
fields fall back to color-scheme roles.

* test(screenshots): add dialogs showcase page

Add DialogsShowcaseScreenshot: a catalog page rendering confirm / dangerous /
alert dialog variants inline so one screenshot shows the dialog theme
(background, surface tint, shape, text and button styling).

Registered like every other screenshot (router + integration test). The logout
ConfirmDialog itself is opened through the real logout button in the existing
SettingScreenScreenshot when the configurator preview runs in interactive mode,
so no pointer handling is hardcoded here.

* fix(theme): keep confirm dialog in its surface and harden preview harness

ConfirmDialog.show/showDangerous anchor to the nearest navigator instead of the app-wide root, so the modal stays inside its hosting surface (e.g. the configurator preview) rather than escaping to the host root navigator and losing the phone's Localizations. The originating theme is preserved by showDialog's InheritedTheme capture, so in-app appearance and behavior are unchanged.

Screenshot harness: the voicemail preview now provides VoicemailPlaybackController (mirroring VoicemailScreenPage) so AudioView resolves it, and the teardown preview renders a side-effect-free stand-in instead of mounting the real TeardownScreen, which calls stopService() and throws when the native platform is not initialized.

* fix(screenshots): render the real TeardownScreen in preview

Mount the actual TeardownScreen instead of a hand-copied stand-in so the preview stays in sync when the screen changes. To avoid its initState stopService() call throwing when SignalingServicePlatform is not initialized (configurator preview / web harness), register a no-op mock platform first, but only when none is set so a real implementation is never overwritten.

* fix(screenshots): catch only StateError when probing signaling platform

SignalingServicePlatform.instance throws StateError when unset, so narrow the guard to that type instead of catching everything, which could mask unrelated failures.

(cherry picked from commit ea2d89a)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants